Published on

[DDD] 첫 도메인 드리븐 도입기

Authors
  • avatar
    Name
    Woojin Son
    Twitter

Intro

2023년의 마지막을 장식하던 프로젝트가 막을 내리고 있습니다. 저는 신규 애플리케이션 REST API 서버 개발을 맡게 되었고, 초기 아키텍쳐 셋업부터 API 개발을 담당하게 되었습니다.

2023년의 중반부 부터 뇌리를 스쳐 지나가던 도메인 드리븐 이라는 주제는 저에게 참 신선했습니다. 무지성으로 개발하던 학부 시절과 인턴 시절, 그리고 부트캠프 시절의 경험을 되짚어보면 아래와 같은 의문이 들었습니다.

  • 현재 백엔드 애플리케이션의 구조는 과연 협업에 도움이 될까?
  • 나는 객체지향을 제대로 이해하고 있는걸까?
  • 팀에 도움이 되는 아키텍쳐는 과연 무엇일까?

2023년 말, 클린 아키텍쳐를 읽으며 많은 고민이 들었습니다. 책 내용에 대한 고민도 있었겠지만, 실제로 이 지식들을 제가 어떻게 써먹을 수 있을까 고민이 들던 찰나, 신규 백엔드 애플리케이션 개발이라는 좋은 기회가 찾아왔습니다.

주니어의 패기만 가지고는 기간 안에 빠르게 개발해야 한다. 라는 요구 사항을 충족하기 힘들다는 것은 충분히 알고 있었습니다. 그래서 경험이 부족한 주니어가 현실적으로 모두에게 도움이 될 수 있는 애플리케이션 아키텍쳐를 설계하려면 어떻게 해야 할 지 많은 고민을 했었습니다.

이번 포스팅에서는 제가 실무에서 백지부터 도메인 드리븐을 도입하며 AA를 진행했던 경험을 공유하고자 합니다.

도메인 드리븐이란?

도메인 패턴을 중심에 놓고 설계하는 방식입니다. Spring 애플리케이션의 도메인 디렉토리만 사용해본 저에겐 도메인 이라는 용어가 참 오묘하게 다가왔습니다. 정확히 도메인이라는 게 무엇일까? 오래 고민을 하다가 아래의 책을 읽게 되었습니다.

도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지

제가 이해한 도메인 드리븐의 의의를 요약하면 아래와 같습니다.

  • 도메인 그 자체, 도메인 로직 그 자체에 집중합니다. JPA, MyBatis등의 인프라스트럭쳐 기술들에 도메인이 종속 되서는 안됍니다.
  • 탑다운 설계가 아닌 데이터 주도로 설계합니다. 생각보다 탑다운 설계는 많은 요구사항 변화에 마주하게 됍니다. UI 에서 요구하는 데이터는 시간에 따라 자주 변화할 수 있습니다.
  • 소프트웨어의 목적 그 자체에 집중합니다. 어떤 화려한 기술을 쓰느냐가 아니라 데이터 중심의 로직에 집중합니다. 어떤 프로그래밍 언어를 쓰거나 인프라 기술을 쓰느냐가 중요한 게 아니라 우리가 해결하고자 하는 문제의 중심에서 어떤 로직이 필요하느냐 가 중요합니다.
  • 소프트웨어 개발 프로젝트에 참여하는 많은 사람들의 의사소통 문제를 해결하고자 하는 목적이 (그것 말고도 사실 이유는 많겠지만요...) 강하다.

도메인은 소프트웨어로써 해결하고자 하는 문제의 영역입니다. 유저의 인증이 될 수도 있고, 장바구니, 결제 등의 영역들 또한 도메인의 영역이라고 할 수 있습니다. 도메인은 해결하고자 하는 문제에 따라 규모가 정해집니다. 가령, 저희 팀에서 개발하고 있는 애플리케이션도 거대한 도메인 영역이라고 할 수 있습니다.

제대로 이해했는 지 모르겠네요...피드백은 언제나 환영입니다. 조금 강해도 좋습니다 ㅎㅎ...

도메인 드리븐을 채택하게 된 이유

제 스스로의 개발 경험을 돌아보면, 기술 그자체에 너무 집중했던 경험이 많이 떠올랐습니다. 학부 시절에는 닷넷 프레임워크와 OpenCV, 파이썬에 빠져서 정작 제 당시 졸업작품이 해결하고자 하는 문제였던 실내의 방역실태 조사 보다는 '내가 파이썬을 쓰고있다', '나는 영상처리를 하고있다' 라는 생각에 더 빠져있던 것 같습니다.

개발 자체는 성공적으로 진행했으나, 기술에 대한 스터디에 너무나 치중한 나머지 정작 서비스의 만족도는 그다지 높지 않았을 것이란 생각이 듭니다. 실제로 제품으로 판매했을 때 소프트웨어를 도입 한 유저들의 의견이 어떨 지 보다는 구현에 집중했었습니다.

이런 마인드셋은 인턴 시절이나 부트캠프를 하던 시절에도 마찬가지였던 것 같습니다. 물론 당시엔 기술에 대한 경험이 부족했다 라는 이야기를 할 수도 있었겠지만 이제는 더이상 기술 그 자체에만 집중 해야 할 시기는 지났다고 생각했습니다.

실제로 백오피스 개발 협업 과정에서도 기술에 대한 경험을 잘 녹여냈으나, 정작 해결하고자 하는 문제를 빠르게 해결했느냐 라는 질문에는 스스로에게 의문을 표합니다.

단순히 시간이 부족해서라고는 생각하지 않습니다. 물론 시간과 리소스관리도 개발자의 퍼포먼스에 큰 영향을 끼칩니다만, 중요한 건 함꼐 일 하는 사람들과의 대화로써의 소통 만큼이나 코드의 소통에 중심을 잡아야 했다고 생각했습니다.

실제로 하나의 서비스 컴포넌트에 각자 담당한 로직이 다른 경우, 코드 충돌을 피하기 위해 다른 담당자를 기다리며 다른 일을 했던경험이 있었고, 장기적으로는 팀원 개개인의 퍼포먼스를 모두 발휘하기 힘든 구조라고 생각했습니다.

그래서 새로 개발하는 REST API 백엔드 애플리케이션에서는 완벽하지는 않더라도 정해진 틀 내에서 각자의 영역을 개발하더라도 코드 충돌을 최소화 하고 싶었습니다.

팀원 모두가 각자의 스타일이 있겠지만, 담당 영역 내에서 그러한 스타일을 반영하되 많은 개발자들이 하나의 리포지토리를 공유해도 충돌을 최소화 할 수 있다면 적어도 상대방을 기다리는 시간은 아낄 수 있을 것이라 생각했습니다.

그 과정에서 JPA를 사용하든, QueryDSL을 어떻게 사용하든 결과적으로 도메인 로직만 잘 작동할 수 있는 구조를 만든다면 코드 너머의 기술들에 대한 선택지도 넓어질 수 있을 것이라 생각했습니다.

JPA나 MyBatis 같은 ORM 에 대한 코드 의존성도 줄일 수 있고 더 나아가 Mongo, Redis와 같은 NoSQL을 선택하더라도 도메인 로직과 관련 된 코드들은 변하지 않겠죠.

하지만 기존에 제가 작성했던 백엔드 애플리케이션 코드로는 스타트를 끊는 입장에서 이런 요구사항을 만족할 수 없다고 생각했습니다. 그래서 도메인 드리븐을 공부하게 되었습니다. 도메인 드리븐을 공부하다보면 자연스럽게 클린 아키텍쳐를 공부하게 됩니다.

완벽한 클린 아키텍쳐는 아니더라도 각 도메인 영역 간에 완충작용을 할 수 있도록 구조를 잡아서 상호간에 코드를 참조하는 일을 최대한 줄이려는 목적으로 도메인 드리븐 도입을 시작했습니다.

어쨌든 적은 리소스로 최대의 퍼포먼스를 내는 게 최고니까요...우리의 업무 시간은 소중합니다.

진행했던 사항들

흔히 헥사고날 아키텍쳐라고 불리는 구조에서 주로 사용하는 디렉토리 구조를 사용하지 않았습니다. 우선 모두에게 익숙한 구조로 디렉토리를 잡되, 위상적으로는 클린 아키텍쳐를 지향하는 방향을 택했습니다.

  • domainName
    • Adapter
      • internal
      • controller
    • Application
      • service
      • utils
    • domain
      • command
      • state
      • entity
      • dto
    • infra
      • repository
      • api

Adapter 디렉토리에는 외부와 통신하는 RestController, 그리고 도메인 패키지 간 통신 역할을 하는 Internal Adapter를 배치했습니다.

만약 PaymentInfo 엔티티와 ShippingInfo 엔티티 간에 상호 작용이 필요하다면 Internal Adapter를 사용하는 샘이죠. 특정 상황에서 두 엔티티가 동시에 데이터 업데이트가 필요 한 경우 유용했습니다.

조회도 반드시 한번에 모든 데이터를 조회하는 경우가 아니라면, 필요한 상황에서 타 도메인의 Internal Adapter를 통해 데이터를 가져오되, DTO를 통해 데이터를 전달함으로써 엔티티 코드에 대한 의존성을 줄였습니다.

특정 도메인에 대한 명령들이 필요할 때는 command DTO를 완충제로 썼습니다.

예를 들면 다른 도메인 엔티티에서 특정 도메인 엔티티의 상태를 확인하고자 할 때, 그런 경우가 여러 도메인에 걸쳐 이루어져 있을 때 유니버셜한 파라미터 셋이 필요하다고 생각했고, 해당 파라미터 셋은 command 디렉토리에 배치했습니다.

예를 들면 아래와 같은 상황입니다. A라는 엔티티를 등록하기 전에 B라는 엔티티가 유효한 지 확인해야 하고 두 엔티티는 서로 다른 도메인 영역에 배치되어 있는 상황입니다.

@Service
class BReadApplication(
    private val bReadService : BReadService
) {
    fun verifyBStatus(command : VerifyBCommand) : BStatusInfo {
        return BStatusInfo.from(bReadService.verifyStatus(command))
    }
}
@Service
class ACommandService(
    private val bReadApplication : BReadApplication,
    private val aRepository : ARepository
) {
    fun registerA(command : register) : AEntity {
        if(bReadApplication.verifyBStatus(VerifyBCommand(command.aId)).canRegister) {
            val aEntity = AEntity.from(command)
            aRepository.save(aEntity)
        } else {
            // 예외 처리를 하거나 등록하되, 미등록 상태로 저장하거나 등등...
        }
    }
}

B 엔티티와 관련 된 엔티티 로직이나 인프라 코드를 쓰지 않고 BReadApplication 이라는 완충제를 통해 각 도메인, 엔티티 간 의존성을 줄였습니다. 또한 verifyBStatus 메소드는 특정한 파라미터 양식만 맞춰준다면 다른 컴포넌트에서도 사용할 수 있습니다.

그 외에 infra 에 배치되는 Repository 코드들은 가능하면 다양한 구현채가 배치 된 인터페이스를 작성하려 했으나, 현재로써는 시간 관계상 JPA Repository와 QueryDSL을 배치하고 쓰고 있습니다. 이 부분은 추후 리펙토링으로 개선해 나갈 예정입니다.

또한 서비스 컴포넌트를 목적 별로 구분했습니다. 당장은 크게 Read / Command 로 분리했습니다. 읽기와 명령을 구분해서 담당자 간 로직 개발에 있어 한 컴포넌트에 담당자가 몰리는 일을 막기 위함이었습니다만 추후 CQRS 를 제대로 적용하고자 하는 목적도 있습니다.

추후 조인이 필요 한 경우에 대한 서비스 컴포넌트는 따로 분리할 예정입니다. 당장은 조인해서 데이터를 가져 올 일은 없었으나, 앞으로 필요 할 수 있다고 생각했습니다.

장단점

장점으로는 패키지 별 코드들의 상호 의존성을 줄일 수 있었습니다. 기존에는 타 디렉토리의 Repository API를 그대로 import 해서 사용하는 경우도 잦았습니다. 이런 경우 당연한 이야기지만, 소스코드 간에 의존성이 거미줄처럼 엮이게 됩니다.

초보 적인 도메인 드리븐이지만 완충제 역할을 하는 어뎁터의 존재로 인해 디렉토리 간 의존성을 조금 더 줄일 수 있었습니다.

또한 엔티티 그 자체의 로직에 집중하게 되므로 추후 Repository 인터페이스를 개선한다면, 구현체에 의존하지 않고 서비스 로직 그 자체의 개발에 집중할 수 있는 여지를 열 수 있습니다.

당장 보이는 단점으로는, 우선 모든 개발자들이 디렉토리 구조에 대해 이해해야 하고 도메인 드리븐에 대한 이해도가 필요로 합니다. 러닝커브 또한 퍼포먼스를 저해하는 요소가 될 수 있습니다.

그리고 작성하는 코드의 양이 훨씬 많아집니다. 단순 탑다운 설계 방식과는 다르게 엔티티 내부부터 설계를 하다보면 엔티티의 로직에 필요한 파라미터 클래스들과 그것들을 매핑해주는 매퍼(일단은 정적 팩토리 메소드로 대체하기는 했습니다만 앞으로 어떻게 될 지 모르죠.), JpaRepository의 쿼리 메소드에만 의존하는 게 아닌 해당 Repository inferface를 구현 한 구현체 등이 필요해집니다.

어뎁터 영역도 마찬가지입니다. 해당 부분에서도 다른 도메인에서 요청을 보낼 수 있도록 파라미터 클래스를 작성해야 합니다.

탑다운으로 설계하고 개발하는 과정에서는 Request VO 그 자체가 비즈니스 로직에 관여를 하는 형태가 나올 수도 있습니다. 사실 너무나 당연한 이야기일 수도 있겠지만, 이런 구조는 그다지 모범적이지 않지만 시간이 부족하거나 도메인 드리븐 설계에 대한 경험이 부족 한 경우 자주 채택하고는 합니다.

개발 시간 만 따지고 보았을 때 이런 설계 방법이 반드시 항상 빠른 개발 시간을 보장해준다고 할 수는 없습니다. 팀의 개발자들이 어떤 구조에서 퍼포먼스를 낼 수 있을 지는 정해진 게 없으니까요.

회고

초보적인 도메인 드리븐 구조로 애플리케이션을 설계하고 개발하는 과정에서 많은 고민이 있었습니다. API 명세서를 빨리 전달 해 줘야 하는 상황에서도 고집스럽게 바텀 업 설계를 진행 할 수는 없을테니 Request VO를 먼저 작성하게 되었습니다. 그 결과 빠르게 로직이 돌아가는데 집중하다보니 특정 로직들은 리펙토링이 필요 하게 되었습니다.

처음부터 완벽 한 설계는 당연히 있을 수 없겠지만, 조금 더 익숙했다면 바텀업으로 코드를 작성 하더라도 큰 문제는 없었겠지 않았나 싶습니다. 많은 API를 작성해야 하는 상황이었지만 개별적인 기능들의 난이도가 헤비한 편은 아니었기 때문입니다.

Request VO 의 Validation 등 고려할 사항들이 점점 생기다보니 프로젝트가 진행 될 수록 리펙토링으로 개선해야 할 코드들이 늘어났고, 결국 기존 방식과 크게 차이없는 시간을 사용했습니다. 그렇지만 한번 경험 해 보았으니 앞으로는 더 잘할 수 있겠다는 생각이 들었습니다.

그리고 로직 별로 담당 역할을 분리 시킬 수 있다보니 확실히 서비스 컴포넌트의 비대함이 줄었습니다. 단순히 AService, BService 에 로직을 몰아넣던 과거와 다르게 비즈니스 로직의 성격에 따라 CUD, R 만 분리해도 코드가 심플해졌습니다. 추후 다른 개발자들이 개발에 참여하더라도 하나의 컴포넌트에 담당자가 몰리지 않게 되었고, 코드 충돌로 인해 생기는 업무 능률 저하를 피할 수 있겠다는 생각이 들었습니다.

Outro

현재의 구조는 절대 완벽하지 않고 초보적입니다. 게다가 도전적이기 까지 했습니다. 그렇지만 새로운 시도를 해본 끝에 과거보다는 좀 더 객체지향 그 자체에 대한 이해를 할 수 있는 기회가 되었다 생각합니다.

과거에 헥사고날 아키텍쳐에 대한 이야기를 하다가 객체지향 그 자체부터 이해하는 게 좋다는 조언을 들었습니다. 객체 간의 협력을 통해 문제를 해결한다는 의미를 이번 기회에 좀 더 공부했다고 생각합니다.

아직은 기능이 많지 않아 코드 량이 많지 않은 상황이라 다행이었다고 생각합니다. 첫 시작은 항상 중요하니까요.